Day 24:Better Auth 登入與收藏做完後,購物車可以沿用同一個資料關係:都是「使用者對某個東西的一筆紀錄」,差別在於購物車還有數量、小計,以及「要不要結帳」的邊界。前 28 天分開用過的 Content
Collections、Actions、Drizzle + Turso 和 Better Auth,第一次在這篇串成一個可操作的小 app。
購物車很容易一路往外長:有了購物車,接著就想加結帳、接金流;金流後面又跟著對帳、算庫存與做訂單狀態機。原本三天能收工的 side
project,兩週後就變成沒人維護得動的半套電商。
「怎麼做出購物車」只是其中一部分;這篇主要回答兩個問題:購物車的每種互動該交給誰,以及功能做到哪裡就停。讀寫依「四條線」分流;這個最小 app 的收手線仍是「購物車資料模型」,只完成資料模型與 CRUD,不接金流。判斷時只問四件事:是否寫 DB、是否只影響 UI、是否需要自訂 HTTP,以及是否已進入完整電商。
每加一個功能都要決定「這要放哪」;缺少固定判準,小 app 很快就會越界。四條線先替每種互動分類,再決定它該交給哪一層。
| 互動 | 交給誰 | 判準 |
|---|---|---|
| 改到 DB 的 mutation(加入 / 改量 / 移除) | Astro Action | 要伺服器驗證輸入、授權、讀 secret、動 DB |
| 純 UI 狀態(數量 stepper 的即時顯示、展開收合) | 純 client island | 不需持久化,伺服器不用知道 |
| 對外 / 自訂 HTTP(Stripe webhook、第三方 callback) | Endpoint(.ts route) |
要拿 raw body、驗簽、控制 status/headers |
| 實際扣款、對帳、庫存鎖定 | 收手(不做) | 這是完整電商,不是「最小 app」 |
要先開工,記住前兩條就夠:動到資料庫的走 Action,只影響畫面的留在 client。後兩條用來判斷專案何時開始跨界;Stripe
webhook 對應對外 HTTP,扣款則越過收手線。
Astro Actions 很適合處理伺服器互動,但不必把每次資料存取都包成 action。商品清單就是一個不該包的例子。
商品清單本身是「純讀、而且可以預渲染」的資料;可以預渲染的頁面在 build 時查,這個 demo 因為要讀登入狀態與 DB,則在 request 時查。兩種情況都可以直接在.astro 的 frontmatter 呼叫 createDb,由伺服器先算好,對 SEO 友善,也不必等 client 端 JS 再送一次請求:
---
import { createDb } from '../../db'; import { products } from '../../db/schema'; import { TURSO_DATABASE_URL,
TURSO_AUTH_TOKEN } from 'astro:env/server';
export const prerender = false; // 這頁要讀登入狀態與 DB,走 on-demand
const db = createDb(TURSO_DATABASE_URL, TURSO_AUTH_TOKEN); // 純讀、可預渲染 → 直接查,不包成 action const catalog =
await db.select().from(products).orderBy(products.name);
---
Action 處理的是 client 互動觸發的伺服器工作。加入購物車、改數量和移除都會立刻改 DB,還要把結果回饋給 UI,這類動作才交給 action。
寫過 Next.js server actions,可能會順手把每個 select 都變成 server
function。在 Astro 裡,能在 build 或 request 時一次算好的資料,不需要再做成由 client 觸發的端點;少一次往返,也能讓程式碼裡「哪些是讀、哪些是寫」清楚分開。
寫 action 前,先決定購物車狀態存在哪,不能只「照抄一種寫法」。常見做法有四種,差別在持久性、登入要求與實作成本:
| 選項 | 優點 | 缺點 | 適用 |
|---|---|---|---|
DB 表(外鍵接 user.id) |
跨裝置持久、伺服器可信任、複用 favorites 已驗證路徑 | 一定要登入、每次操作一趟 DB、要處理匿名 merge | 會員制、購物車要長期保存 |
Signed cookie(只存 {id, qty}) |
匿名也能用、零 DB、edge 友善 | 約 4KB 上限、需自簽防竄改、跨裝置不同步 | 想支援匿名、品項數少 |
| Server session | 安全、可放較多資料 | 需 session 儲存後端,徒增複雜度 | 已有成熟 session 基建 |
| localStorage | 零後端、實作最快 | SSR 讀不到、action 無法驗證、清快取就消失 | 純前端 demo,不作為正式購物車 |
這個最小 app 選 DB 表 + 強制登入;這只是依現有條件取捨,不代表「DB 一定最好」。Better
Auth、Turso 都已經存在,購物車可以沿用
Day 24:Better Auth 登入與收藏驗證過的「登入 → 寫 DB」路徑,不必另做 cookie 簽章或匿名購物車 merge。未登入時回UNAUTHORIZED 並引導登入,跟 toggleFavorite 一樣。少引入一種狀態儲存機制,就少一組電商特有的同步邏輯。
金額欄位有一個不能省的細節:價格要用「最小單位整數」(分)儲存,不用浮點數。0.1 + 0.2 在浮點數裡不等於0.3,這類誤差不能出現在金額計算裡。接著在 src/db/schema.ts 加入兩張表:
export const products = sqliteTable("products", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
priceCents: integer("price_cents").notNull(), // 最小單位整數,避免浮點誤差
description: text("description"),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const cartItems = sqliteTable(
"cart_items",
{
id: integer("id").primaryKey({ autoIncrement: true }),
// 外鍵接 Better Auth 的 user.id;刪 user 連帶清空購物車
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// 外鍵接自家 products.id;商品刪除連帶移出購物車
productId: integer("product_id")
.notNull()
.references(() => products.id, { onDelete: "cascade" }),
qty: integer("qty").notNull().default(1),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// 一位使用者 × 一個商品在購物車只有一列,支撐下一節的 upsert 累加
(table) => [unique("cart_user_product_unq").on(table.userId, table.productId)],
);
cart_items 的複合 unique 是後面 upsert 判定衝突列的依據。schema 加完後,執行npm run db:generate && npm run db:migrate,產生並套用 migration。
同一個商品第二次「加入購物車」時,數量要累加在原列,不能再新增一列或直接覆蓋原數量。前面的複合 unique 搭配 Drizzle 的onConflictDoUpdate,可以在一次寫入裡完成:
addToCart: defineAction({
input: z.object({
productId: z.coerce.number().int().positive(),
qty: z.coerce.number().int().min(1).max(99).default(1),
}),
handler: async ({ productId, qty }, ctx) => {
const user = ctx.locals.user;
if (!user) throw new ActionError({ code: 'UNAUTHORIZED', message: '請先登入才能加入購物車' });
const db = createDb(TURSO_DATABASE_URL, TURSO_AUTH_TOKEN);
// never trust client:前端傳來的 productId 一律重查存在
const [p] = await db.select().from(products).where(eq(products.id, productId));
if (!p) throw new ActionError({ code: 'NOT_FOUND', message: '找不到這個商品' });
// 命中 (user, product) 唯一鍵就把數量加上去,而不是新增一列
await db.insert(cartItems)
.values({ userId: user.id, productId, qty })
.onConflictDoUpdate({
target: [cartItems.userId, cartItems.productId],
set: { qty: sql`${cartItems.qty} + ${qty}` },
});
return { ok: true };
},
}),
授權與輸入驗證要分開。授權直接讀 handler 裡的 ctx.locals.user,action 不必再查 session,因為
Day 23:middleware 與 locals 的分工已把登入狀態放進 locals。但 ctx.locals.user
存在,只能證明使用者已登入,不能證明 productId 有效;前端可以傳入任何數字,伺服器仍要重查商品是否存在。
用 node scripts/cart-smoke.ts 對本機 file:local.db 實跑後,結果如下:
=== 購物車(upsert 後)===
Astro 貼紙包 ×3 → 小計 36000 分
Islands 馬克杯 ×1 → 小計 32000 分
貼紙包數量應為 3:實際 3
總計 68000 分(= NT$680)
貼紙包分兩次加入(1 + 2),資料庫裡仍只有一列,數量是 3;onConflictDoUpdate 的 qty = qty + n 已生效。
改數量和移除都會寫 DB,因此也交給 action。這兩支使用accept: 'form',讓購物車列可以直接用純 HTML 表單送出;即使關掉 JavaScript,操作仍然有效(漸進增強):
setQty: defineAction({
accept: 'form',
input: z.object({
productId: z.coerce.number().int(),
qty: z.coerce.number().int().min(0).max(99),
}),
handler: async ({ productId, qty }, ctx) => {
const user = ctx.locals.user;
if (!user) throw new ActionError({ code: 'UNAUTHORIZED', message: '請先登入' });
const db = createDb(TURSO_DATABASE_URL, TURSO_AUTH_TOKEN);
const where = and(eq(cartItems.userId, user.id), eq(cartItems.productId, productId));
if (qty === 0) { await db.delete(cartItems).where(where); return { removed: true }; }
await db.update(cartItems).set({ qty }).where(where);
return { qty };
},
}),
頁面端用一個普通的 <form> POST 到這個 action,數量欄就是一個 <input type="number">:
<form method="POST" action="{actions.setQty}">
<input type="hidden" name="productId" value="{r.productId}" />
<input type="number" name="qty" value="{r.qty}" min="0" max="99" />
<button type="submit">更新</button>
</form>
Astro 5 之後到 7,表單 action 出錯後不會自動 redirect 回上一頁,成功與失敗都要自行處理。frontmatter 用Astro.getActionResult
取得結果;這一頁每次 request 都會重新查購物車,因此 POST 後重新渲染就會拿到最新明細,不必手動導頁:
const qtyResult = Astro.getActionResult(actions.setQty); // 小計一律在伺服器用 DB 的 price 重算,永不信任前端傳來的價格
const totalCents = cart.reduce((s, r) => s + r.priceCents * r.qty, 0);
小計要在伺服器重算,不能直接採用前端傳來的總價,因為使用者可以修改前端送出的數字。價格以 DB 為準:數量由使用者輸入,每筆小計仍由伺服器用資料庫裡的 price 乘上數量,這才是此處never trust client 的意思。
數量 stepper 的「+ /
−」按鈕只負責即時更新畫面,屬於純 UI,不必每按一次就呼叫 action。使用者確認數量後再送出即可;Action 只在這時接手,符合「純 UI 留在 client」的分工。
這個最小 app 一次都用不到資料庫交易(transaction)。是否需要交易,可以用來判斷功能走到哪裡。
這裡的 CRUD 都是單筆寫入:加一列、改一列、刪一列。單筆寫入本身具備原子性,不必再包交易。要做到「多列一起成功或一起失敗」,例如「下單時扣庫存 + 建訂單 + 清購物車」,才需要多步原子操作;那已經屬於完整電商。
一旦操作需要跨多列的交易一致性,功能就已經離開「內容站加一點互動」的範圍,進入電商後端,也越過本篇的收手線。
部署環境也會影響這條邊界。這個 app 跑在 Cloudflare Workers,透過 @libsql/client/web 以 HTTP 連接 Turso;互動式db.transaction() 需要在多次往返間維持連線狀態,而純 HTTP、無狀態的 web
client 對此支援是已知的敏感點。若要在 Workers 上執行原子的多步寫入,較穩妥的做法是db.batch([...]):一次送出多條,並維持整批成敗一致;這個最小購物車連 batch 都不需要。
schema 內的外鍵連帶刪除(cascade)則能直接驗證。兩個外鍵都設定 onDelete: 'cascade',實跑結果如下:
PRAGMA foreign_keys = 1
=== 外鍵 cascade ===
刪 user 後其 cart_items 剩餘 0 列(0 = cascade 生效)
刪掉一個 user,他的購物車明細自動清空。
這份證據仍有一個缺口,範圍與 Day 24:Better Auth 登入與收藏相同:測試使用 Node 的@libsql/client,其中 foreign_keys 預設開啟,cascade 也確實生效;app 部署時則使用 @libsql/client/web 的 HTTP
transport,真實 workerd 裡的 cascade 行為尚未驗證。完整驗證需要 turso dev + wrangler dev。這個缺口只限於 HTTP
transport 的 cascade;最小購物車仍維持單筆寫入,不再延伸到互動式交易。
若要接金流,Stripe Checkout 的最小流程分成三步:
astro:env/server、在請求當下建 client(跟 DB 同一條規則)。這一步可以放 action 或 endpoint。return Astro.redirect(session.url)。信用卡欄位、3DS 全在 Stripe 網域,自家站不碰卡號。stripe.webhooks.constructEvent(rawBody, sig, secret)第三步對應「四條線」裡的「對外 HTTP」:webhook 必須走 endpoint,不能走 action。它要用原始 request body 驗證stripe-signature,也要自行控制 status code;Astro
Action 是站內、型別安全的 RPC,會先把 body 解析成型別化輸入,webhook 驗簽需要的卻是未處理的 raw
body。「為什麼不是所有伺服器互動都塞進 Action」的答案,在於 raw body 與回應控制權不同。
這個最小 app 做到「購物車資料模型 + CRUD」就停。建立 Checkout
Session 之後的扣款、webhook 驗簽與對帳、訂單狀態機、庫存一致性都屬於完整電商,本篇不做,也不安裝 stripe 依賴。demo 頁保留一顆停用的「前往結帳」按鈕,直接標示功能邊界。
讀寫分流後,每個互動都有固定位置:商品清單是 frontmatter 裡的純讀,加入購物車是 action 裡的 mutation,數量即時變化留在 client,金流則走 endpoint 或停在收手線外。這種分工也比較好測:測addToCart 不必連前端一起跑,修改清單查詢也不會碰到寫入邏輯。若把這些互動混在一起,後續修改的影響範圍就很難判斷。
每個互動都套用同一組判準:寫入走 Action、純 UI 留在 client、對外 HTTP 走 endpoint,扣款則停在範圍外。四條線先定好,最小 app 才不會在加功能時一路長成半套電商。
購物車採 DB + 強制登入,因為可以沿用現有的 Better
Auth 與 Turso。金額用整數儲存,小計由伺服器重算,避免浮點誤差與前端竄改。這裡沒有交易,因為功能還停在單筆 CRUD,尚未進入多步寫入的電商流程。
明天是
Day 30:Astro 專案選型,30 天系列會以一個更大的問題收尾:哪些專案適合 Astro,哪些不適合。「知道在哪收手」也是選型判斷的一部分。
今日驗收:完成 products / cart_items 兩張表與三個 action(addToCart / setQty /removeFromCart),/demos/cart 可操作並呈現四條邊界;npm run db:cart-smoke
已驗證 upsert 累加、join 小計與外鍵 cascade,npm run build(Cloudflare adapter)通過。